// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using Microsoft.Win32.SafeHandles;

namespace Microsoft.DotNet.Cli.Utils;

/// <summary>
/// Responsible for reaping a target process if the current process terminates.
/// </summary>
/// <remarks>
/// On Windows, a job object will be used to ensure the termination of the target
/// process (and its tree) even if the current process is rudely terminated.
///
/// On POSIX systems, the reaper will handle SIGTERM and attempt to forward the
/// signal to the target process only.
///
/// The reaper also suppresses SIGINT in the current process to allow the target
/// process to handle the signal.
/// </remarks>
internal class ProcessReaper : IDisposable
{
    /// <summary>
    /// Creates a new process reaper.
    /// </summary>
    /// <param name="process">The target process to reap if the current process terminates. The process should not yet be started.</param>
    public ProcessReaper(Process process)
    {
        _process = process;

        // The tests need the event handlers registered prior to spawning the child to prevent a race
        // where the child writes output the test expects before the intermediate dotnet process
        // has registered the event handlers to handle the signals the tests will generate.
        Console.CancelKeyPress += HandleCancelKeyPress;
        if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            _shutdownMutex = new Mutex();
            AppDomain.CurrentDomain.ProcessExit += HandleProcessExit;
        }
    }

    /// <summary>
    /// Call to notify the reaper that the process has started.
    /// </summary>
    public void NotifyProcessStarted()
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            // Limit the use of job objects to versions of Windows that support nested jobs (i.e. Windows 8/2012 or later).
            // Ideally, we would check for some new API export or OS feature instead of the OS version,
            // but nested jobs are transparently implemented with respect to the Job Objects API.
            // Note: Windows 8.1 and later may report as Windows 8 (see https://docs.microsoft.com/en-us/windows/desktop/sysinfo/operating-system-version).
            //       However, for the purpose of this check that is still sufficient.
            if (Environment.OSVersion.Version.Major > 6 ||
                (Environment.OSVersion.Version.Major == 6 && Environment.OSVersion.Version.Minor >= 2))
            {
                _job = AssignProcessToJobObject(_process.Handle);
            }
        }
    }

    public void Dispose()
    {
        if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            if (_job != null)
            {
                // Clear the kill on close flag because the child process terminated successfully
                // If this fails, then we have no choice but to terminate any remaining processes in the job
                SetKillOnJobClose(_job.DangerousGetHandle(), false);

                _job.Dispose();
                _job = null;
            }
        }
        else
        {
            AppDomain.CurrentDomain.ProcessExit -= HandleProcessExit;

            // If there's been a shutdown via the process exit handler,
            // this will block the current thread so we don't race with the CLR shutdown
            // from the signal handler.
            if (_shutdownMutex != null)
            {
                _shutdownMutex.WaitOne();
                _shutdownMutex.ReleaseMutex();
                _shutdownMutex.Dispose();
                _shutdownMutex = null;
            }
        }

        Console.CancelKeyPress -= HandleCancelKeyPress;
    }

    private static void HandleCancelKeyPress(object? sender, ConsoleCancelEventArgs e)
    {
        // Ignore SIGINT/SIGQUIT so that the process can handle the signal
        e.Cancel = true;
    }

    private static SafeWaitHandle? AssignProcessToJobObject(IntPtr process)
    {
        var job = NativeMethods.Windows.CreateJobObjectW(IntPtr.Zero, null);
        if (job == null || job.IsInvalid)
        {
            return null;
        }

        if (!SetKillOnJobClose(job.DangerousGetHandle(), true))
        {
            job.Dispose();
            return null;
        }

        if (!NativeMethods.Windows.AssignProcessToJobObject(job.DangerousGetHandle(), process))
        {
            job.Dispose();
            return null;
        }

        return job;
    }

    private void HandleProcessExit(object? sender, EventArgs args)
    {
        int processId;
        try
        {
            processId = _process.Id;
        }
        catch (InvalidOperationException)
        {
            // The process hasn't started yet; nothing to signal
            return;
        }

        // Take ownership of the shutdown mutex; this will ensure that the other
        // thread also waiting on the process to exit won't complete CLR shutdown before
        // this one does.
        _shutdownMutex?.WaitOne();

        if (!_process.WaitForExit(0) && NativeMethods.Posix.kill(processId, NativeMethods.Posix.SIGTERM) != 0)
        {
            // Couldn't send the signal, don't wait
            return;
        }

        // If SIGTERM was ignored by the target, then we'll still wait
        _process.WaitForExit();

        Environment.ExitCode = _process.ExitCode;
    }

    private static bool SetKillOnJobClose(IntPtr job, bool value)
    {
        var information = new NativeMethods.Windows.JobObjectExtendedLimitInformation
        {
            BasicLimitInformation = new NativeMethods.Windows.JobObjectBasicLimitInformation
            {
                LimitFlags = (value ? NativeMethods.Windows.JobObjectLimitFlags.JobObjectLimitKillOnJobClose : 0)
            }
        };

        var length = Marshal.SizeOf(typeof(NativeMethods.Windows.JobObjectExtendedLimitInformation));
        var informationPtr = Marshal.AllocHGlobal(length);

        try
        {
            Marshal.StructureToPtr(information, informationPtr, false);

            if (!NativeMethods.Windows.SetInformationJobObject(
                job,
                NativeMethods.Windows.JobObjectInfoClass.JobObjectExtendedLimitInformation,
                informationPtr,
                (uint)length))
            {
                return false;
            }

            return true;
        }
        finally
        {
            Marshal.FreeHGlobal(informationPtr);
        }
    }

    private Process _process;
    private SafeWaitHandle? _job;
    private Mutex? _shutdownMutex;
}
